Skip to content

fix(compiler): read [class=…] from the prop the class list arrives on - #448

Open
YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/class-attribute-selector
Open

fix(compiler): read [class=…] from the prop the class list arrives on#448
YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/class-attribute-selector

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

[class=…], [class~=…] and [class] match nothing, on every element. CSS 2.1 §5.8.1's own worked example is span[class=example].

Problem

An attribute query built from the name class reads props.class. React Native delivers the class list as className, so the prop never exists and every operator answers false:

selector element native
.test[class~='example'] <Text className="test example" /> no match
.test[class*='xamp'] same no match
.test[class] same no match

Solution

One mapping, applied at both build sites. The compiler already knows it — a second class name in the same compound builds ["a", "className", "*=", name] — so this routes both sites through attributePropName rather than adding a third spelling.

function attributePropName(name: string): string {
  return name === "class" ? "className" : name;
}

Test plan

Five cases in src/__tests__/native/attributes.test.tsx, covering =, ~=, *=, presence, and the :is() build site. The = case pins both directions of §6.1 exact matching — the value compared is the WHOLE class list, so [class='test example'] matches className="test example" and [class='example'] does not, which is what a browser answers for span[class=example]. Mutation-proved: making attributePropName the identity turns all five red and nothing else.

yarn typecheck and yarn lint clean; yarn test 1053 passed, 3 failed, 21 skipped. The three failures are two babel suites that fail identically on an untouched main worktree (Windows-only module-specifier rewrites) — this touches no babel file.

Note

Independent of #447 — that one is what the operators do with a value, this is a name that never resolves. Both touch the same two build sites, so whichever lands second needs a trivial rebase. #449 is a third, same story.

CSS 2.1 §5.8.1's own example is `span[class=example]`, and it matches
nothing here: an attribute query built from the name `class` reads
`props.class`, which no React Native element has, so every element answers
false for every operator.

The compiler already knows the mapping — a second class name in the same
compound builds `["a", "className", "*=", name]` — so this routes both
attribute-query build sites through one `attributePropName` rather than
adding a third spelling of it.
`[class=…]` is the headline case and the four cases covered `~=`, `*=`,
presence and the `:is()` build site — so the mutation proof never
exercised the operator in the title.

The new case pins both directions of §6.1's exact match: the value it
compares is the WHOLE class list, so `[class='test example']` matches
`className="test example"` and `[class='example']` does not — the same
answer a browser gives for `span[class=example]`.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — All three bars are blue. The query reads props.class — a prop no React Native element has — so the includes-match answers false for every element on the screen.

FIXED — The first bar is red and the other two are blue. [class~=…] reads the class list off className, which is the prop React Native delivers it on.

before — stock 3.0.7 after — with this PR

One rule governs all three bars — .probe[class~="flagged"] over a blue base — and they differ only in their own class list: one carries the word the query names, one carries a different word, one carries no second class at all.

The second and third bars are the anti-vacuity pair. They are blue in both frames, and the third is what says the withheld case looks like nothing rather than like everything: two bars that had both lost the rule would agree with each other perfectly, so the miss alone proves nothing.

Both frames come from the same device in the same run (Android 36 emulator, 1140×2400 @ 480dpi), but before is stock 3.0.7 rather than "this build minus this PR" — so one unrelated difference is visible and worth naming rather than leaving for you to spot. The compiler's inlineRem defaults to 14 and our build sets it to 16, so every rem-derived length in the before frame renders at 87.5% of the after one. That is a different fix, not this one.

The build-probe width=<dp> line is that same rem fold used as a build stamp — a 3rem box, so 42 unfixed and 48 fixed. The capture harness reads it off the device and refuses to save a frame whose probe disagrees with the variant it claims, so a before image cannot silently be a second after.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant